在數學上,有些函數在定義域的某些值會沒有定義,例如,當定義域是所有實數,函數f(x)=1/x,則f(0)是沒有意義。此時,我們會將x=0從我們的定義域剔除;然而,在資訊的世界,我們無法強迫使用者都是給合法的輸入,我們如果還要讓函數在定義域的每個值都能有定義,我們只好將我們原來的對應域加上一個叫做「無意義」的值,成為新的對應域。
如果我們在python定義下列函數
import math
def sqrt(x):
return math.sqrt(x)
def decrement(x):
return x - 1
def reciprocal(x):
return 1 / x
def f(x):
return reciprocal(decrement(sqrt(x)))
接著我們分別執行print(f(1))
和print(f(-3))
,我們會分別得到ZeroDivisionError: float division by zero和ValueError: math domain error,這是因為計算f(1)時,我們會先計算sqrt(1)得到1,再計算decrement(1)得到0,再計算reciprocal(0)時,此時分母為0,所以得到ZeroDivisionError;
當計算f(-3)時,sqrt(-3)便會得到math domain error,因為根號運算不能有負值,所以兩者皆為拋出錯誤而中斷程式的執行,造成困擾,我們必須用try except來處理錯誤,讓程式可以繼續進行,不被中斷。
我們如果在typescript中定義相同的函數,
const sqrt = (x: number): number => Math.sqrt(x)
const decrement = (x: number): number => x - 1
const reciprocal = (x: number): number => 1 / x
const f = (x: number): number => reciprocal(decrement(sqrt(x)))
如果我們執行console.log(f(-3))
,卻不會拋出錯誤,而是得到NaN,而且程式不會中斷。Javascript的number型別除了一般的數字還有一個很特別的值叫NaN,當我們運算式在數學上沒有定義時,也就是無法計算時,我們便會得到NaN這個值,代表的是「無意義」,這個值不會中斷我們函數的合成,而會無條件傳遞下去;也就是說,一旦合成過程中的任一步驟得到NaN這個值,那最後合成的結果一定是NaN。如此便可以不用使用try catch語法,仍可保持程式的順暢。
至於執行console.log(f(-3))
則會得到Infinity,這是number型別另一個特別的值,但它不會無條件傳遞下去,它會以「不定型極限」的規則進行,最後的結果有可能是Infinity或NaN。
在函數式程式設計中,替所有的型別處理都設計了「無意義」的型別建構子,稱之為Maybe或Option,fp-ts中扮演這個角色的型別建構子稱為Option,Option本身不是一種型別,而是型別建構子,必須代入型別參數才能成為型別實體,它由Some型別建構子和None型別聯集而得,它們的定義如下:
interface None {
readonly _tag: 'None'
}
interface Some<A> {
readonly _tag: 'Some'
readonly value: A
}
type Option<A> = None | Some<A>
fp-ts/Option模組提供幾個建構函數,如of、some和fromNullable。of和some是相同的建構函數,of這個名字強調它的一般性,fp-ts的所有容器幾乎都有這個建構子,不需要額外記憶,some只有在Option這個容器才能使用,它們會得到Some的型別。none則是型別為None的常數,fromNullable則是會將undefined和null的輸入轉為None的輸出,其它的輸入則會得到Some的輸出。
import { none, some, fromNullable } from 'fp-ts/Option'
assert.deepStrictEqual(fromNullable(undefined), none)
assert.deepStrictEqual(fromNullable(null), none)
assert.deepStrictEqual(fromNullable(1), some(1))
我們重新修改前面的程式碼,將函數的型別定義獨立成一行,如此比較方便知道他們的輸入與輸出的型別,另外我們將sqrt函數改成safeSqrt,它的輸入依舊是number,但是輸出型別變成Option
import { Option, none, of, map } from 'fp-ts/Option'
import { pipe, flow } from 'fp-ts/function'
type SafeSqrt = (x: number) => Option<number>;
const safeSqrt: SafeSqrt = (x) => (x < 0 ? none : pipe(x, Math.sqrt, some));
type Decrement = (x: number) => number;
const decrement: Decrement = (x) => x - 1;
type Reciprocal = (x: number) => number;
const reciprocal: Reciprocal = (x) => 1 / x;
const f = flow(
safeSqrt,
decrement, //Argument of type 'Decrement' is not assignable to parameter of type '(b: Option<number>) => number'.
reciprocal
)
此時我們若直接合成safeSqrt, decrement, reciprocal這三個程式,typescript將會出現抱怨訊息,因為safeSqrt的輸出型別為Option,decrement的輸入型別卻是number,兩者不同,所以不能合成。Option也是一個Functor,所以Option模組中也提供了map函數,map(decrement)也是一個函數,它的輸入和輸出型別都是Option,map(decrement)這個函數的行為很簡單,如果輸入是None,則輸出也是None;如果輸入是some(value),則輸出是some(decrement(value)),這個特性很像NaN,在整個合成的過程中產生遞延性。同樣地,我們也必須map(reciprocal),修正後的f如下:
const f = flow(
safeSqrt,
map(decrement),
map(reciprocal)
)
此時,f的輸入型別是number, 輸出型別是Option,但是我們希望能取得Some型別內的值,那才是我們需要的。
fp-ts/Option模組內提供了幾個取值的函數,分別是getOrElse、match和matchW,這三者的輸入都是Option型別。
// f1 :: number -> number
const f1 = flow(
safeSqrt,
map(decrement),
map(reciprocal),
getOrelse(() => NaN) // NaN的型別是number,符合規定
)
// f2 :: number -> string
const f2 = flow(
safeSqrt,
map(decrement),
map(reciprocal),
match(
() => '無意義', // 這是onNone函數
(x) => `輸出的值是${x}` // 這是onSome函數
)
)
// f3 :: number -> number | string
const f3 = flow(
safeSqrt,
map(decrement),
map(reciprocal),
match(
() => '無意義', // 這是onNone函數
(x) => x // 這是onSome函數
)
)
最後,我們再看一個例子:
type SafeHead = <T>(as: T[]) => Option<T>;
const safeHead: SafeHead = (as) => (as.length > 0 ? of(as[0]) : none);
type G = (as: number[]) => string;
const g: G = flow(
safeHead,
map(decrement),
match(
() => '空陣列',
(x) => `第一個元素是${x}`
)
)
safeHead的輸入是一個陣列,因為可能遇到空陣列,讓函數的合成順利,所以函數的輸出設計為Option,函數g限制輸入陣列為number陣列,同樣地,我們必須map(decrement),如此才能合成,最後我們用match輸出都是string,所以函數g的輸出是string。
Option的使用方式大致便是如此,當你的函數可能undefined或null時,你可以設計函數的輸出為Option,中間的處理可以設計函數的輸出和輸入都是typescript型別,但是合成的時候要記得先map,最後再用getOrElse、getOrElseW、match和matchW等函數取值出來。
Array模組所提供的utils函數大多是具備型別安全,也就是其輸出是Option所建構的型別,例如head函數便和上面所提的safeHead相同,以下列舉分三類列舉這些函數。
1.head、last、tail、init
head函數如前述,last則取最後一個元素,tail則扣除第一個元素剩餘元素所成的陣列,init則扣除最後一個元素剩餘元素所成的陣列;如果輸入的陣列是空陣列,則得到的結果是none。
console.log(init([1, 2, 3])); // some([2, 3])
console.log(init([1])); // some([])
console.log(init([])); // none
console.log(tail([1, 2, 3])); // some([1, 2])
console.log(tail([1])); // some()
console.log(tail([])); // none
2.findFirst、findLast、findIndex、findLastIndex、lookup
findFirst、findLast、findIndex、findLastIndex需要提供一個Predicate<A>
型別(即輸入是型別A,輸出是boolean)當作第一個參數,第二個參數則是Array<A>
型別,findIndex、findLastIndex的輸出型別是Array<Option<number>>
;
const isPositive = (x: number) => x > 0;
console.log(findFirst(isPositive)([-3, 2, -4, 5])); // some(2)
console.log(findIndex(isPositive)([-3, 2, -4, 5])); // some(1)
console.log(findLast(isPositive)([-3, 2, -4, 5])); // some(5)
console.log(findLastIndex(isPositive)([-3, 2, -4, 5])); // som(3)
console.log(findFirst(isPositive)([-3])); // none
console.log(findIndex(isPositive)([-3])); // none
console.log(findLast(isPositive)([-3])); // none
console.log(findLastIndex(isPositive)([-3])); // none
lookup函數則接受一個number型別的參數作為搜尋的註標(index),第二個參數是Array<A>
型別,輸出是Option<A>
。
console.log(lookup(2)([1, 2, 3, 4])); // some(3)
console.log(lookup(2)([])); // none
3.insertAt, updateAt, deleteAT
insertAt的第一個參數是number(index)和型別A(新增的元素)組成的元組(Tuple),第二個參數則是型別A的陣列,回傳型別是Option<Array<A>>
。如果第一個參數的註標大於第二個參數陣列的長度則回傳none。
console.log(insertAt(2, 5)([1, 2, 3])); //some([1, 2, 5, 3])
console.log(insertAt(2, 5)([1])); // none
updateAt的第一個參數是number(index)和型別A(新增的元素)組成的元組(Tuple),第二個參數則是型別A的陣列,回傳型別是Option<Array<A>>
。如果第一個參數的註標大於或等於第二個參數陣列的長度則回傳none。
console.log(updateAt(2, 5)([1, 2, 3])); //some([1, 2, 5])
console.log(updateAt(1, 5)([1])); // none
deleteAt的第一個參數是number(index),第二個參數則是型別A的陣列,回傳型別是Option<Array<A>>
。如果第一個參數的註標大於或等於第二個參數陣列的長度則回傳none。
console.log(deleteAt(1)([1, 2, 3])); //some([1, 3])
console.log(deleteAt(1)([1])); // none
雖然Option Functor可以確保數個函數順利合成,不致於因為null或undefined的輸入值造成整個程式的中斷,由於在合成的許多函數步驟中,我們無法知道是在那一個函數輸出none,無法提供足夠的錯誤訊息。如果希望能夠程式合成又能提供相關的錯誤訊息,那麼就是Either Functor登場的時刻。
我們先看fp-ts中Either型別建構子的定義:
type Either<E, A> = Left<E> | Right<A>
Either這個型別建構子由Left和Right兩個型別建構子聯集而成,分別需要不同的型別參數E和A。Right型別建構子扮演Option中Some型別建構子的角色,保留有意義的值;Right型別建構子則扮演None型別,和None不同的地方在於Right是型別建構子,通常帶入我們定義的錯誤訊息型別,通常我們會固定我們事先定義的錯誤訊息型別E,如此就只需要一個型別參數A。
Either模組提供的基本建構函數有right和left,我們用下面的程式碼來說明:
import { pipe, flow } from 'fp-ts/function';
import { Either, Right, Left, left, right, map, match } from 'fp-ts/Either';
const trace =
<A>(tag: string) =>
(x: A): A => {
console.log(tag, x);
return x;
};
type EvaluationError =
| { type: 'DIVISION'; message: string }
| { type: 'SQRT'; message: string };
const divisionError: EvaluationError = {
type: 'DIVISION',
message: '除數不能為0',
};
const sqrtError: EvaluationError = {
type: 'SQRT',
message: '根號裏面不能有負的',
};
type SafeSqrt = (x: number) => Either<EvaluationError, number>;
const safeSqrt: SafeSqrt = (x) =>
x < 0 ? left(sqrtError) : pipe(x, Math.sqrt, right);
type Decrement = (x: number) => number;
const decrement: Decrement = (x) => x - 1;
type Reciprocal = (x: number) => number;
const reciprocal: Reciprocal = (x) => 1 / x;
const f = flow(
safeSqrt,
map(decrement),
trace('decrement'),
map(reciprocal),
match(
(e) => e.message,
(x) => `您得到的值是${x}`
)
);
console.log(f(25)); // 您得到的值是0.25
console.log(f(-3)); // 根號裏面不能有負的
console.log(f(1)); // 您得到的值是Infinity
以上面的例子來看,Either Functor和Option Functor的行為很類似,right和some的部分基本上是一樣的,而left和none一旦出現,都會傳遞下去,兩者的差異在於,left產生的錯誤訊息也會一直傳遞下去。
計算f(1)的時候,我們會得到「您得到的值Infinity」的答案,但是這並不是我們想要的結果,我們希望說得到divisionError的錯誤訊息,如果我們將reciprocal改寫成下面的程式碼safeReciprocal執行,會得到什麼樣的結果?
type SafeReciproca = (x: number) => Either<EvaluationError, number>;
const safeReciprocal: SafeReciproca = (x) =>
x === 0 ? left(divisionError) : right(1 / x);
我們來檢視map(decrement)和map(safeReciproca)的輸出入簽署,map(decrement)的輸出型別是Either<EvaluationError, number>
,而map(SafeReciproca)的輸入也是Either<EvaluationError, number>
,因此函數的合成是合法的,接下來我們檢視map(SafeReciproca)的輸出,它的輸出是Either<EvaluationError, Either<EvaluationError, number>>
,也就是嵌套了兩層Either,這個問題的處理,我們會在後面Monade的部分來說明。
今天介紹了fp-ts中負責錯誤處理的Option
和Either<E>
型別建構容器,由javascript number型別中NaN可以了解Option的運作邏輯。Option確保函數在合成時順利,而Either<E>
除了保證函數的合成順利,更能保留錯誤訊息。
今天也詳細說明了Array模組中許多的型別安全函數,型別安全的代價是多了一層嵌套,必須了解和熟悉相對應的函數式程式設計嵌套處理機制,我們會在後續的發文中討論這些機制,今天分享的內容就到此為止,明天再見。